-------------The Print Shop------------
A 4am crack                  2024-12-25
                     updated 2024-12-27

Name: The Print Shop
Genre: graphics
Year: 1984
Credits: David Balsam, Martin Kahn
Publisher: Broderbund Software
Platform: Apple ][ (48K)
Media: 5.25-inch disk
Sides: 2
OS: custom
Previous cracks: Mr. Krak-Man,
  many others

  _________________________________
 /\                                \
 \_| ESTRAGON                      |
   |   I can't go on like this.    |
   | VLADIMIR                      |
   |   That's what you think.      |
   | ESTRAGON:                     |
   |   If we parted? That might be |
   |   better for us.              |
   | VLADIMIR                      |
   |   We'll hang ourselves        |
   |   tomorrow. (Pause) Unless    |
   |   Godot comes.                |
   | ESTRAGON                      |
   |   And if he comes?            |
   | VLADIMIR                      |
   |   We'll be saved.             |
   |                               |
   |        -- "Waiting for Godot" |
   |   ____________________________|_
    \_/______________________________/


...............CHAPTER 0...............
 IN WHICH VARIOUS AUTOMATED TOOLS FAIL
          IN INTERESTING WAYS

COPYA
  read error on last pass

Locksmith Fast Disk Backup
  unable to read track $22
  copy displays title screen then hangs
    on main menu

EDD 4 bit copy (no sync, no count)
  read error on track $22
  copy displays title screen then hangs
    on main menu

Copy ][+ nibble editor
  track $22 is quite unusual, with
  repeated sequences of $D4 $D5 $DE $D4
  and other nibbles, with and without
  timing bits

                 --v--

   COPY ][ PLUS BIT COPY PROGRAM 8.4
(C) 1982-9 CENTRAL POINT SOFTWARE, INC.
---------------------------------------

TRACK: 22  START: 1800  LENGTH: 3DFF

2020: FF+FF+FF+D4 D5 DE D4 AA   VIEW
2028: AA F5 AA+FF+DA+9A BB DA
2030: 95 AA FD D5 FF FF D4 D5
2038: DE D4 AB AA F5 AA FF+FF+
2040: D4 D5 DE D4 AB AB F5 AA+ <-2040
2048: A6+FF+A6 AE F6 A5+AA EF
2050: B5+FF+FF+FF+D4 D5 DE D4
2058: AA AF F5 AA FF+C9+D4 D5  FIND:
2060: DE D4 AB AE F5 AA FF+FF+ D4 D5 DE

                 --^--

Disk Edit
  track 0 has a custom bootloader
  track $11 has a DOS 3.3-style disk
    catalog but it contains only fake
    files that display a copyright
    message
  no way to read track $22 (no sectors)

Why didn't COPYA work?
  non-standard structure on track $22

Why didn't Locksmith FDB / EDD work?
  presumably there is some runtime
  protection check that triggers after
  displaying the main menu, which
  checks the structure of track $22

Next steps:

  1. find runtime protection check
  2. disable it
  3. declare victory (*)

(*) go on


...............CHAPTER 1...............
 IN WHICH WE FIND THE CODE THAT READS
THE TRACK THAT CAN'T BE READ BY COPIERS

   _______
  /  ___  | ince my copy goes down a
 |  (__ \_| different code path than
  '.___`-.  the original, I'm guessing
 |`\____) | there is a runtime check
 |_______.' somewhere. One thing that
all on-disk checks have in common is
they need to turn on the drive motor by
accessing a specific address in the
$C0xx range. For slot 6, it's $C0E9,
but to allow disks to boot from any
slot, developers usually use code like
this:

  LDX <slot number x 16>
  LDA $C089,X

There's nothing that says where the
slot number has to be, although the
disk controller ROM routine uses zero
page $2B and lots of disks just reuse
that. There's also nothing that says
you have to use the X-register as the
index, or that you must use the
accumulator as the load register. But
most RWTS code does, out of convention
I suppose (or possibly fear of messing
up such low-level code in subtle ways).

Also, since developers don't actually
want people finding their protection-
related code, they may try to encrypt
it or obfuscate it to prevent people
from finding it. But eventually, the
code must exist and the code must run,
and it must run on my machine, and I
have the final say on what my machine
does or does not do.

But sometimes you get lucky.

Turning to my trusty Disk Edit sector
editor, I search the non-working copy
for "BD 89 C0", which is the opcode
sequence for "LDA $C089,X".

[Disk Edit]
  ["F"ind]
    ["H"ex]
      ["BD 89 C0"]

                 --v--

------------ DISK SEARCH --------------

$00/$08-$09   $00/$08-$3E   $00/$0A-$E5
$00/$0F-$16   $00/$0F-$6C   $07/$0A-$6A
$10/$06-$26   $10/$0D-$4F

                 --^--

The matches on track $00 are part of
an in-app backup system (more on this
later). The match on track $07 seems
uninteresting. T10,S0D is part of a
late-loaded but standard DOS 3.3 RWTS.

The other match on track $10 might be
my jackpot. The protection routine
appears to start at offset $19:

                 --v--

T10,S06
----------- DISASSEMBLY MODE ----------
; initialize an entire page of memory
; at $BB00
0019:A0 00          LDY   #$00
001B:A9 FF          LDA   #$FF
001D:99 00 BB       STA   $BB00,Y
0020:C8             INY
0021:D0 FA          BNE   $001D

; turn on drive motor manually (always
; suspicious)
0023:AE F8 05       LDX   $05F8
0026:BD 89 C0       LDA   $C089,X

; look for that nibble sequence I saw
; in the nibble editor: $D4 $D5 $DE $D4
0029:BD 8C C0       LDA   $C08C,X
002C:10 FB          BPL   $0029
002E:C9 D4          CMP   #$D4
0030:D0 F1          BNE   $0023

; This subroutine just does the LDA/BPL
; loop to get the next nibble. But it
; tells me that this code is probably
; executing from $B6xx in memory.
0032:20 E5 B6       JSR   $B6E5
0035:C9 D5          CMP   #$D5
0037:D0 F5          BNE   $002E
0039:20 E5 B6       JSR   $B6E5
003C:C9 DE          CMP   #$DE
003E:D0 F5          BNE   $0035
0040:20 E5 B6       JSR   $B6E5
0043:C9 D4          CMP   #$D4
0045:D0 F5          BNE   $003C
0047:EA             NOP

; get a 4-and-4 encoded value from the
; next two nibbles
0048:BD 8C C0       LDA   $C08C,X
004B:10 FB          BPL   $0048
004D:2A             ROL
004E:85 26          STA   $26
0050:BD 8C C0       LDA   $C08C,X
0053:10 FB          BPL   $0050
0055:25 26          AND   $26

; transfer value into Y register
0057:A8             TAY

; continue below
0058:4C B5 B6       JMP   $B6B5
.
.
.
; now check for an epilogue of sorts,
; the two-nibble sequence $F5 $AA
00B5:20 E5 B6       JSR   $B6E5
00B8:C9 F5          CMP   #$F5
00BA:D0 20          BNE   $00DC
00BC:20 E5 B6       JSR   $B6E5
00BF:C9 AA          CMP   #$AA
00C1:D0 19          BNE   $00DC

; Using the 4-and-4 encoded value as
; a lookup into the page we initialized
; earlier, see if we've seen this value
; already. (We initialized all 256
; addresses with #$FF.)
00C3:B9 00 BB       LDA   $BB00,Y

; if we've found this value already,
; skip ahead
00C6:10 14          BPL   $00DC

; otherwise mark this value as "found"
00C8:A9 00          LDA   #$00
00CA:99 00 BB       STA   $BB00,Y

; loop through the array of "found"
; values and count how many unique
; values we've seen
00CD:AA             TAX
00CE:A8             TAY
00CF:B9 00 BB       LDA   $BB00,Y
00D2:30 01          BMI   $00D5
00D4:E8             INX
00D5:C8             INY
00D6:D0 F7          BNE   $00CF

; have we seen #$A0 unique values yet?
00D8:E0 A0          CPX   #$A0

; yes, we're done
00DA:B0 03          BCS   $00DF

; no, loop back and read some more
00DC:4C 23 B6       JMP   $B623

; turn off drive motor and exit
00DF:AE F8 05       LDX   $05F8
00E2:BD 88 C0       LDA   $C088,X
[falls through to subroutine that
 reads a single nibble, then returns]

                 --^--

Well that certainly explains why my
Locksmith Fast Disk Backup copy hung --
it's looking for a nibble sequence
($D4 $D5 $DE $D4) that doesn't exist
because it didn't read anything from
track $22. But why can't EDD copy this?
What's so special about track $22?


...............CHAPTER 2...............
     IN WHICH WE DECODE THE DATA ON
THE TRACK THAT CAN'T BE READ BY COPIERS

  _____
 |_   _| can edit my non-working bit
   | |   copy (created by EDD 4) to
   | |   insert some useful code before
  _| |_  the protection check starts.
 |_____| That sector (T10,S06) is in
memory at $B600, and my code looks like
this:

[S6,D1=non-working copy]

; new code
B619-   20 EB B6    JSR   $B6EB
B61C-   EA          NOP

; original code
B61D-   99 00 BB    STA   $BB00,Y
B620-   C8          INY
B621-   D0 FA       BNE   $B61D
...
; new code --
; set reset vector to jump to monitor
B6EB-   A9 69       LDA   #$69
B6ED-   8D F2 03    STA   $03F2
B6F0-   A9 FF       LDA   #$FF
B6F2-   8D F3 03    STA   $03F3
B6F5-   49 A5       EOR   #$A5
B6F7-   8D F4 03    STA   $03F4

; reproduce original code from $B619
; and return
B6FA-   A0 00       LDY   #$00
B6FC-   A9 FF       LDA   #$FF
B6FE-   60          RTS

Now I can press <Ctrl-Reset> and jump
to the monitor while the protection
check is running.

*C600G
...protection check runs and hangs...
<Ctrl-Reset>

Now I'm in the monitor and have
unfettered access to all of memory.
Let's look at the buffer at $BB00 and
see which encoded values are being
found and which aren't.

*BB00.BBFF

BB00- 00 00 00 00 FF FF 00 FF
BB08- 00 FF FF FF 00 00 00 00
BB10- 00 00 00 FF FF 00 00 00
BB18- FF 00 FF 00 FF FF 00 FF
BB20- 00 00 00 FF 00 00 FF 00

[truncated for brevity and also because
 it turns out not to be as illuminating
 as I had hoped]

I can also reuse the counting routine
(at $B6CA) to find out how many values
we're finding.

*B6CAL

; new code here
B6CA-   A9 00       LDA   #$00
B6CC-   EA          NOP

; original code here
B6CD-   AA          TAX
B6CE-   A8          TAY
B6CF-   B9 00 BB    LDA   $BB00,Y
B6D2-   30 01       BMI   $B6D5
B6D4-   E8          INX
B6D5-   C8          INY
B6D6-   D0 F7       BNE   $B6CF

; new code here --
; move the count of zero values to A
; and print it as a hex value
B6D8-   8A          TXA
B6D9-   4C DA FD    JMP   $FDDA

*B6CAG
7E

The minimum number of unique 4-and-4
encoded values required to pass the
protection is #$A0 (160), but we're
only seeing #$7E (126). So we're way
short.

Why are we missing so many values?

The first value missing from my bit
copy was 5 (in location $BB05), so
let's search the original disk for the
nibble sequence $AA $AF $F5. That's the
4-and-4 encoded value ($AA $AF), plus
the first nibble of the expected
epilogue ($F5 $AA).

Copy II Plus again:

                 --v--

TRACK: 22  START: 1800  LENGTH: 3DFF

2AC8: D4 AB AA F5 AA AA AA AA   VIEW
2AD0: D4 D5 DE D4 AB AB F5 AA
2AD8: FF FF 9A 9A BB DA 95 AB
2AE0: BD D5 FF 9B D4 D5 DE D4
                  ^^^^^^^^^^^
2AE8: AA AF F5 AA FF FF A6 AE  <-2AE8
      ^^^^^ ^^^^^
2AF0: F6 A5 BA EF B5 FF FF B5
2AF8: D5 DE D4 AB AF F5 AA FF
2B00: FF 9A BB DA 95 D5 BD D5  FIND:
2B08: FF EC D4 D5 DE D4 AE AB  AA AF F5

                 --^--

I highlighted the three relevant
sequences, starting at offset $2AE4:

  - $D4 $D5 $DE $D4 (prologue)
  - $AA $AF         (encoded value)
  - $F5 $AA         (epilogue)

In that same screenshot, we can see
evidence of several other values:

  - $AB $AA (2) at offset $2AC9
  - $AB $AB (3) at offset $2AD4
  - $AE $AB (9) at offset $2B0E

This confirms what running the
protection code already told me: the
original disk contains the group of
(prologue)(4-4-encoded value)(epilogue)
where the encoded value is 5.

Now let's see what my non-working bit
copy looks like.

[S6,D1=non-working copy]

                 --v--

TRACK: 22  START: 1800  LENGTH: 3DFF

2430: AA FF D4 D5 DE D4 AA AB   VIEW
            ^^^^^^^^^^^
2438: F5 AA AA AA D4 D5 DE D4
                  ^^^^^^^^^^^
2440: AB AA F5 AA AA AA D4 D5
                        ^^^^^
2448: DE D4 AB AB F5 AA FF B3
      ^^^^^
2450: A6 AE F6 A5 AA EF B5 FF  <-2450
2458: FF A9 AB BD A9 AA BF D6
2460: FF FF D4 D5 DE D4 AB AE
            ^^^^^^^^^^^
2468: F5 AA FF FF A6 AE F6 A5  FIND:
2470: BA FF B5 96 FF D4 D5 DE  D4 D5 DE
                     ^^^^^^^^

                 --^--

We can see the track contains lots of
groups with 4-and-4 encoded values:

  - $AA $AB (1) at offset $2436
  - $AB $AA (2) at offset $2440
  - $AB $AB (3) at offset $244A
  - $AB $AE (6) at offset $2466

But no group contains $AA $AF (5)
anywhere in the track.

I am no closer to understanding why.


...............CHAPTER 3...............
 IN WHICH WE LEARN WHY THE DATA ON THE
 TRACK THAT CAN'T BE READ BY COPIERS
      CAN'T BE READ BY COPIERS

  _____
 |_   _|    et's return to the original
   | |      disk and examine the
   | |   _  uncopyable track $22 again.
  _| |__/ | There must be something
 |________| that explains why the best
bit copiers in the world make such a
mess of this track.

[S6,D1=original disk]

                 --v--

TRACK: 22  START: 1800  LENGTH: 3DFF

2F58: DE D4 AB AA F5 AA AA AA   VIEW
2F60: AA D4 D5 DE D4 AB AB F5
2F68: AA FF 9A 9A BB DA 95 AB
2F70: BD D5 80 80 B5 D5 DE D4
                  ??^^^^^^^^^
2F78: AA AF F5 AA AA AA FF A6  <-2F78
      ^^^^^
2F80: AE F6 A5 BA EF B5 FF FF
2F88: 9A 9A BB DA 95 EB FD D5
2F90: FF FF 9A 9A BB DA 95 D5  FIND:
2F98: BD D5 FF FF 9A 9A BB DA  AA AF F5

                 --^--

Waaaaait a minute. That can't be right.
There's the 4-and-4 encoded value for 5
($AA $AF, at offset $2F78), and the
epilogue after it ($F5 $AA, at offset
$2F7A). But look at the prologue before
it. There should be $D4 $D5 $DE $D4
at offset $2F74, but the first nibble
is missing.

Maybe there are two copies of this
group and I just found the other one
this time? No, this is only instance of
$AA $AF $F5 on the track.

Side note: I realize the offsets are
different, but they're not relevant to
this mystery. They depend on where the
disk was spinning when Copy II Plus
started reading the track this time.
That could be anywhere; I'd expect the
offsets to change every time I re-read
a track.

But the nibbles themselves should be
the same. That's the data on the disk.
Data on the disk shouldn't change every
time you read it. That's kind of the
point of a storage device.

Unless...

Oh no.

Oh God.

Oh God no.

There's only one thing you can put on a
disk that will change every time you
read it: nothing. And by "nothing," I
mean "a long sequence of '0' bits."
And that's what is on the original disk
between each of these groups: nothing.

A bit of background. When we say a
"0" bit, we really mean "the lack of
a magnetic state change." The Disk II
drive isn't digital; it's analog. If it
doesn't see a state change in a certain
period of time, it calls that a "0". If
it does see a change, it calls that a
"1". But the drive can only tolerate a
lack of state changes for so long --
about as long as it takes for two bits
to go by.

Fun fact(*): this is why you need to
use nibbles as an intermediate on-disk
format in the first place. No valid
nibble contains more than two "0" bits
consecutively, when written from most-
significant to least-significant bit.

(*) not guaranteed, actual fun may vary

So what happens when a drive doesn't
see a state change after the equivalent
of two consecutive "0" bits? The drive
thinks the disk is weak, and it starts
increasing the amplification to try to
compensate, looking for a valid signal.
But there is no signal. There is no
data. There is just a yawning abyss of
nothingness. Eventually, the drive gets
desperate and amplifies so much that it
starts returning random bits based on
ambient noise from the disk motor and
the magnetism of the Earth.

Seriously.

It's trivial to write "0" bits to a
disk. You can write whatever you want
to a disk; it doesn't need to be what
DOS would consider a "valid" nibble.
You can write a #$00 nibble like any
other 8-bit nibble, and it'll write 8
"0" bits. But when you read that
nibble back, the drive can't handle 8
"0" bits in a row, so it will actually
return some random bits. Which is why
no one does that.

Returning random bits doesn't sound
very useful for a storage device, but
it's exactly what the developer wanted,
and that's exactly what this copy
protection scheme depends on. Here's
why:

Bit copiers can't duplicate a long
sequence of "0" bits.

Why? Because that's not what they see.
What they see is some random bits --
the real "0" bits interspersed with
phantom "1" bits. So that's what they
write to the target disk. Whatever
randomness they get when they read the
original disk will essentially get
"frozen" onto the copy.

Now, why does this matter? Let's look
at the protection code again. It looks
for the prologue $D4 $D5 $DE $D4, then
it checks the 4-and-4 encoded value
that follows and marks that value as
"found" in the buffer at $BB00. But
each prologue is preceded by a
bitstream that changes every time it's
read. Sometimes those random bits will
align in such a way that they form two
valid nibbles, and the next prologue
will be read correctly. Other times,
they will only form a partial nibble
that is completed by the first few bits
of the prologue, so the prologue will
be missed.

As far as I can tell, the sequence of
"0" bits is 18 bits long. I'm not sure
the exact length matters. The important
part is the randomness they produce.

Here's what's on the disk (the $D4 and
$D5 are the start of the prologue):

  /--00--\/--00--\/--D4--\/--D5--\
0000000000000000001101010011010101

But remember, more than two consecutive
"0" bits will form an "abyss" that the
floppy drive will fill with randomness.
Some of those "0" bits before the
prologue will randomly transform into
"1" bits, and it'll be a different set
every time you read the disk. How does
that affect the protection check?
Here's one of many possible bitstreams
that might come out of the abyss:

  /--FF--\/--9B--\/--D4--\/--D5--\
0011111111100110111101010011010101

That's what I saw the first time I read
the original disk with the Copy II Plus
nibble editor: the abyss coalesces into
two full nibbles ($FF $9B), then I read
the prologue ($D4 $D5 and so on).

Here's another possible bitstream that
might come out of the abyss:

/--80--\/--80--\/--B5--\  /--D5--\
1000000010000000101101010011010101

That's what I saw (on the same disk!)
the second time I read it, as shown at
the beginning of this chapter. This
time, the abyss coalesces into two and
a half nibbles -- $80, $80, and a few
extra bits. Those extra bits combine
with the bits that are supposed to be
part of the $D4 nibble, but because
we're in the middle of a nibble when
the $D4 bits start, we end up finishing
that nibble ($B5) instead, and the $D4
nibble disappears.

The first nibble of the prologue, $D4,
gets consumed by the abyss.

The second nibble of the prologue, $D5,
survives unscathed, but by then it's
too late. Without the full prologue,
we'll skip this entire group and the
4-and-4 encoded value within it.

The protection code requires finding
160 unique 4-and-4 encoded values after
a full prologue ($D4 $D5 $DE $D4). The
loop at $B6CD counts the number of
values it's found by incrementing the X
register for every value marked "found"
in the buffer at $BB00. At $B6D8, it
compares X to #$A0 and branches to
$B6DF to return to the caller once it's
found enough unique values. It doesn't
care which values it finds, or in which
order; it only cares about the total
count.

If you re-read the original disk enough
times, each stream of random bits will
(eventually) align so the next prologue
is (eventually) read correctly and
enough different encoded values are
(eventually) marked as "found." You'll
miss some of the prologues each time
you read the track, but you'll
(eventually) find them all as the
random bits fluctuate. So the check
only passes after some number of reads
of a nondeterministic bitstream that
sometimes (but not always) corrupts the
data after it.

But bit copiers don't preserve long
streams of "0" bits. Instead, they
write out whatever phantom "1" bits
they find. On a copy, you'll also miss
some percentage of prologues -- and
thus skip over some percentage of the
4-and-4 encoded values -- each time you
read the track. But that percentage
will never increase no matter how many
times you read it. No more randomness.
No more "eventually."

God, I hate physical objects.


...............CHAPTER 4...............
 IN WHICH WE PATCH THE CODE THAT READS
THE TRACK THAT CAN'T BE READ BY COPIERS
   AND DISCOVER WE ARE BEING WATCHED

  _________
 |  _   _  | he protection check at
 |_/ | | \_| $B619 has no "success" or
     | |     "failure" path. It just
    _| |_    tries forever to find 160
   |_____|   unique values within this
weird region on track $22, then returns
without even setting a flag. Completion
is success.

The obvious patch, then, is to put an
"RTS" at the beginning of the routine
so it always returns.

T10,S06,$19: A0 -> 60

[...reboot...]
[...seems to work...]
[...try printing a sign...]
[...printer outputs reams and reams
    of garbage...]

That is the most obnoxious failure mode
I have ever seen. I love it.

What's happening here is that the low-
level protection check (at $B619) is
being bypassed, but elsewhere in the
program, it has noticed my subterfuge.
This tamper check could be anywhere,
and it could be implemented in several
ways. The program acts like nothing is
wrong until it's time to print. Maybe
that's when it runs the tamper check on
$B619; maybe it ran it much earlier and
just let me think everything was fine
until I tried to print. Which is kind
of the point. It's the Print Shop. Of
course you would ruin a pirate's
ability to print.

After several attempts, it appears that
the entirety of the routine at $B619 is
tamper-checked. Putting an "RTS" opcode
anywhere will result in the same print
failure. Any change to the logic to try
to fool it into thinking it's found
enough values... print failure. This
suggests the program is calculating a
checksum on the entire region of memory
used by the protection check.

I will dig into the actual tamper check
later, but next I want to find where
$B619 is called. If I can't modify this
subroutine, maybe I can modify the code
that calls it.

On my non-working copy, I changed the
code that ends up at $B619 to read the
last return address from the stack and
break into the monitor:

B619-   BA          TSX
B61A-   BD 01 01    LDA   $0101,X
B61D-   8D 00 B6    STA   $B600
B620-   BD 02 01    LDA   $0102,X
B623-   8D 01 B6    STA   $B601
B626-   4C 59 FF    JMP   $FF59

Rebooting, I can see where this routine
was called by looking at the values I
stored in $B600 and $B601.

*B600.B601
B600- 08 78

Due to how the Apple II stack works,
that address ($7808) is actually 1 byte
before the next instruction to be
executed after $B619 returns, which
means it's 2 bytes after the JSR that
called $B619.

*7806L

7806-   20 D7 8D    JSR   $8DD7

This might look surprising because it's
not calling $B619, but it's actually
fine. If this is the return address on
the stack, it means that $8DD7 jumps to
$B619 before returning. A JMP does not
affect the stack; as far as the CPU is
concerned, it's just a continuation of
the same subroutine.

That means we can start at $8DD7 and
trace forwards until execution gets to
$B619, which is much easier than using
the stack to trace further backwards.

*8DD7L

8DD7-   EE F5 8D    INC   $8DF5
8DDA-   AD F5 8D    LDA   $8DF5
8DDD-   29 03       AND   #$03
8DDF-   D0 F4       BNE   $8DD5

$8DD5 is an "RTS", so this will only
continue to the protection check every
fourth time it's called. The counter
starts at #$03 on boot, so the check
runs the first time this is routine
called but not the next 3 times. Maybe
this routine is called from multiple
places and they later decided to reduce
the disk access? Unclear.

; set up some RWTS parameters
8DE1-   A9 01       LDA   #$01
8DE3-   8D EA B7    STA   $B7EA
8DE6-   8D F8 B7    STA   $B7F8

; some light obfuscation to hide the
; jump address
8DE9-   A9 AB       LDA   #$AB
8DEB-   49 17       EOR   #$17
8DED-   8D F4 8D    STA   $8DF4
8DF0-   6C F3 8D    JMP   ($8DF3)

; since we've broken into the monitor
; after this code has run, we can see
; the target address: $BCE0
8DF3-   E0 BC

*BCE0L

; set up some more RWTS parameters
BCE0-   A9 00       LDA   #$00
BCE2-   8D F4 B7    STA   $B7F4
BCE5-   A9 22       LDA   #$22
BCE7-   8D EC B7    STA   $B7EC

; seek to track $22
BCEA-   20 FC BC    JSR   $BCFC

; exit via the protection check at
; $B619 (aha!)
BCED-   4C 19 B6    JMP   $B619
BCF0-   EA          NOP
BCF1-   EA          NOP
BCF2-   EA          NOP
BCF3-   EA          NOP
BCF4-   EA          NOP
BCF5-   EA          NOP
BCF6-   EA          NOP
BCF7-   EA          NOP
BCF8-   EA          NOP
BCF9-   EA          NOP
BCFA-   EA          NOP
BCFB-   EA          NOP
BCFC-   A0 E8       LDY   #$E8
BCFE-   A9 B7       LDA   #$B7
[falls through to the RWTS entry point
 at $BD00]

The obvious patch, then, is to put an
"RTS" at $BCE0 so it always returns.

T10,S0C,$E0: A9 -> 60

[...reboot...]
[...seems to work...]
[...try printing a sign...]
[...printer outputs reams and reams
    of garbage...]

The protection code at $B619 is tamper-
checked. The calling code at $BCE0 is
also tamper-checked. I love it. You're
not paranoid if they really are out to
get you.

The obvious patch, then, is to put an
"RTS" at $8DD7 so it always returns.
Searching for "EE F5 8D" in Disk Edit,
I find the routine on disk on T0D,S09.

T0D,S09,$DB: EE -> 60

[...reboot...]
[...seems to work...]
[...try printing a sign...]
[...printer prints a sign...]

Hallelujah.

As elegant as this may seem, my friend
qkumba suggested an even smaller patch.
The routine at $8DD7 is incrementing a
counter then doing AND #$03 and exiting
early if the result is not zero. By
changing the "AND" to an "ORA", the
result will never be zero, and the code
with always exit early.

4? No, not a good time.
5? Also not a good time.
8? 12? Actually it's never a good time
to run the protection check. But please
keep tamper-checking to make sure the
protection code is intact.

I find this darkly humorous.

T0D,S09,$E1: 29 -> 09

And there it is: the rare 1-bit crack.


...............APPENDIX A..............
           "NO TOUCHY FISHY"
IN WHICH WE FIND THE CODE THAT WATCHES
THE CODE THAT CALLS THE CODE THAT READS
THE TRACK THAT CAN'T BE READ BY COPIERS

       __
      /  \      s I discovered in the
     / /\ \     most hilarious way, the
    / ____ \    protection check and
  _/ /    \ \_  its calling code are
 |____|  |____| tamper-checked during
printing. Here is the routine that
implements that tamper check. It's on
disk at T0D,S09,$A6, in memory at
$8DA2.

; initialize checksum
8DA2-   A9 55       LDA   #$55
8DA4-   8D D6 8D    STA   $8DD6

; update checksum
8DA7-   A9 3C       LDA   #$3C
8DA9-   A2 E0       LDX   #$E0
8DAB-   A0 0F       LDY   #$0F
8DAD-   20 BF 8D    JSR   $8DBF

; and again
8DB0-   A9 36       LDA   #$36
8DB2-   A2 19       LDX   #$19
8DB4-   A0 3F       LDY   #$3F
8DB6-   20 BF 8D    JSR   $8DBF

; and again
8DB9-   A9 36       LDA   #$36
8DBB-   A2 B5       LDX   #$B5
8DBD-   A0 35       LDY   #$35
[execution falls through to $8DBF]

; The checksum update subroutine takes
; the three parameters in X, Y, and A.
; X is the low byte of the address.
8DBF-   86 DE       STX   $DE

; A is the high byte of the address
; with bit 7 stripped (so minus $80)
8DC1-   0A          ASL
8DC2-   38          SEC
8DC3-   6A          ROR
8DC4-   85 DF       STA   $DF

; Y is the length of the bytes that
; contributes to the checksum (minus 1)
8DC6-   98          TYA

; update checksum based on each byte
; in the given region
8DC7-   51 DE       EOR   ($DE),Y
8DC9-   18          CLC
8DCA-   6D D6 8D    ADC   $8DD6
8DCD-   8D D6 8D    STA   $8DD6
8DD0-   88          DEY
8DD1-   10 F3       BPL   $8DC6

; munge the address on the way out
; just because f--- you, that's why
8DD3-   06 DF       ASL   $DF
8DD5-   60          RTS

Revisiting the three calls to this
subroutine:

; $10 bytes at $BCE0 (the protection
; check caller that exits via $B619)
8DA7-   A9 3C       LDA   #$3C
8DA9-   A2 E0       LDX   #$E0
8DAB-   A0 0F       LDY   #$0F

; $40 bytes at $B619 (the first half of
; the protection check)
8DB0-   A9 36       LDA   #$36
8DB2-   A2 19       LDX   #$19
8DB4-   A0 3F       LDY   #$3F

; $36 bytes at $B6B5 (the second half
; of the protection check)
8DB9-   A9 36       LDA   #$36
8DBB-   A2 B5       LDX   #$B5
8DBD-   A0 35       LDY   #$35

This tamper check is called a lot. Like
a lot a lot. How often? Literally every
time any string is printed on screen as
part of the program's user interface.
By the time you can interact with the
main menu, Print Shop has called its
protection check once and its tamper
check 13 times.


...............APPENDIX B..............
         "NO GODS, NO MASTERS"
 IN WHICH WE FIND THE CODE THAT COPIES
THE TRACK THAT CAN'T BE READ BY COPIERS

  ______
 |_   __ \  rint Shop has an incredible
   | |__) | feature hidden in plain
   |  ___/  sight. If you press Esc
  _| |_     during boot, it launches an
 |_____|    in-app backup utility that
allows you to make one (and only one)
protected backup. It then deauthorizes
the original disk so it can't be used
to make further copies. Both the backup
and the deauthorized master can boot
and pass the protection check like the
original master disk as shipped.

The code for the backup utility lives
on track $00, like me.

                 --v--

T00,S05
----------- DISASSEMBLY MODE ----------
0033:AD 00 C0       LDA   $C000
0036:2C 10 C0       BIT   $C010
0039:C9 9B          CMP   #$9B
003B:D0 0A          BNE   $0047
003D:AD 00 10       LDA   $1000
0040:F0 05          BEQ   $0047
0042:4C 00 10       JMP   $1000

                 --^--

I can patch that JMP at offset $42 and
break to the monitor with the backup
utility in memory.

[S6,D1=non-working copy]
T00,S05,$43: 00 10 -> 59 FF

[...reboot...]
[...press Esc...]
[...breaks to monitor...]

*1000L

1000-   4C E1 14    JMP   $14E1

*14E1L

[...some initialization code...]

14EE-   20 03 10    JSR   $1003

*1003L

; seek to track $22 using standard RWTS
; calls (not shown)
1003-   20 52 10    JSR   $1052
1006-   AE F8 05    LDX   $05F8

; turn on drive motor manually
1009-   BD 89 C0    LDA   $C089,X
100C-   A9 2C       LDA   #$2C
100E-   85 E0       STA   $E0
1010-   A0 00       LDY   #$00
1012-   88          DEY
1013-   D0 04       BNE   $1019
1015-   C6 E0       DEC   $E0
1017-   F0 1D       BEQ   $1036

; find a nibble sequence, $A5 $DF $D4
1019-   BD 8C C0    LDA   $C08C,X
101C-   10 FB       BPL   $1019
101E-   C9 A5       CMP   #$A5
1020-   D0 F0       BNE   $1012
1022-   BD 8C C0    LDA   $C08C,X
1025-   10 FB       BPL   $1022
1027-   C9 DF       CMP   #$DF
1029-   D0 F3       BNE   $101E
102B-   BD 8C C0    LDA   $C08C,X
102E-   10 FB       BPL   $102B
1030-   C9 D4       CMP   #$D4
1032-   D0 F3       BNE   $1027

; carry bit clear if we found it,
; carry bit set if we didn't
1034-   18          CLC
1035-   24 38       BIT   $38

; turn off drive motor and exit
1037-   BD 88 C0    LDA   $C088,X
103A-   60          RTS

This is a quick check to ensure that
the original disk is in the drive, but
it's not the full protection check that
counts 4-and-4-encoded values within
abyss-delimited nibble groups. This
check passes on my non-working copy.

In fact, this nibble sequence ($A5 $DF
$D4) doesn't factor into the protection
check at all. Which means there is
something else on track $22 that we
haven't discovered yet.

After the subroutine at $1003 returns,
execution continues at $14F1...

14F1-   A9 08       LDA   #$08
14F3-   20 DC 12    JSR   $12DC

*12DCL

; again seek to track $22 (not shown)
12DC-   8D EF 12    STA   $12EF
12DF-   20 52 10    JSR   $1052

; again turn on the drive motor
; manually
12E2-   AE F8 05    LDX   $05F8
12E5-   BD 89 C0    LDA   $C089,X
12E8-   A9 04       LDA   #$04
12EA-   85 E6       STA   $E6
12EC-   A0 00       LDY   #$00

; this value was self-modified earlier
; and it looks like it's being used
; as the high byte of an address, so
; ($E7) -> $0800
12EE-   A9 08       LDA   #$08
12F0-   84 E7       STY   $E7
12F2-   85 E8       STA   $E8

; look for that same nibble sequence
; $A5 $DF $D4
12F4-   BD 8C C0    LDA   $C08C,X
12F7-   10 FB       BPL   $12F4
12F9-   C9 A5       CMP   #$A5
12FB-   D0 F7       BNE   $12F4
12FD-   BD 8C C0    LDA   $C08C,X
1300-   10 FB       BPL   $12FD
1302-   C9 DF       CMP   #$DF
1304-   D0 F3       BNE   $12F9
1306-   BD 8C C0    LDA   $C08C,X
1309-   10 FB       BPL   $1306
130B-   C9 D4       CMP   #$D4
130D-   D0 F3       BNE   $1302

; read $100 bytes of 4-and-4-encoded
; data and store it at ($E7), which
; starts at $0800
130F-   BD 8C C0    LDA   $C08C,X
1312-   10 FB       BPL   $130F
1314-   2A          ROL
1315-   85 E5       STA   $E5
1317-   BD 8C C0    LDA   $C08C,X
131A-   10 FB       BPL   $1317
131C-   25 E5       AND   $E5
131E-   91 E7       STA   ($E7),Y
1320-   C8          INY
1321-   D0 EC       BNE   $130F
1323-   0E FF FF    ASL   $FFFF

; a 1-nibble epilogue
1326-   BD 8C C0    LDA   $C08C,X
1329-   10 FB       BPL   $1326
132B-   C9 CF       CMP   #$CF
132D-   D0 B9       BNE   $12E8

; increment target address and
; decrement counter (initialized to
; #$04 at $12EA, so we're reading $400
; bytes total into $0800..$0BFF)
132F-   E6 E8       INC   $E8
1331-   C6 E6       DEC   $E6
1333-   D0 DA       BNE   $130F

; turn off drive motor and exit
1335-   BD 88 C0    LDA   $C088,X
1338-   60          RTS

What did we just read from track $22?
We can put an "RTS" at $14F6 (just
after this subroutine is called) and
find out.

*14F6:60   ; was #$2C
*1000G
[...read read read...]

There are two independent routines, one
at $0800 and one at $0900. They do not
call each other. The rest of the region
is zeroes.

I'll start with the routine at $0900.

; set up disk for writing
0900-   A6 08       LDX   $08
0902-   BD 8D C0    LDA   $C08D,X
0905-   BD 8E C0    LDA   $C08E,X
0908-   A0 00       LDY   #$00
090A-   A9 00       LDA   #$00
090C-   9D 8F C0    STA   $C08F,X
090F-   1D 8C C0    ORA   $C08C,X
0912-   EA          NOP
0913-   EA          NOP
0914-   EA          NOP

0915-   A9 00       LDA   #$00
0917-   20 5F 09    JSR   $095F

*95FL

; write one nibble
095F-   A6 08       LDX   $08
0961-   EA          NOP
0962-   EA          NOP
0963-   EA          NOP
0964-   9D 8D C0    STA   $C08D,X
0967-   DD 8C C0    CMP   $C08C,X
096A-   60          RTS

Note that we just wrote a #$00 nibble,
a.k.a. 8 "0" bits in a row, which as I
mentioned earlier is something you can
just... do.

; write nibble prologue $D4 $D5 $DE $D4
091A-   A9 D4       LDA   #$D4
091C-   20 5F 09    JSR   $095F
091F-   A9 D5       LDA   #$D5
0921-   20 5F 09    JSR   $095F
0924-   A9 DE       LDA   #$DE
0926-   20 5F 09    JSR   $095F
0929-   A9 D4       LDA   #$D4
092B-   20 5F 09    JSR   $095F
092E-   98          TYA
092F-   20 4D 09    JSR   $094D

*94DL

; write first nibble of a 4-and-4-
; encoded value
094D-   48          PHA
094E-   EA          NOP
094F-   4A          LSR
0950-   09 AA       ORA   #$AA
0952-   9D 8D C0    STA   $C08D,X
0955-   DD 8C C0    CMP   $C08C,X
0958-   68          PLA
0959-   09 AA       ORA   #$AA
095B-   EA          NOP
095C-   EA          NOP
095D-   EA          NOP
095E-   EA          NOP
[falls through to $095F to write the
 second nibble of the 4-and-4-encoded
 value]

Continuing from $0932...

; write the nibble epilogue $F5 $AA
0932-   A9 F5       LDA   #$F5
0934-   20 5F 09    JSR   $095F
0937-   A9 AA       LDA   #$AA
0939-   20 5F 09    JSR   $095F

; write another 8 "0" bits in a row
093C-   A9 00       LDA   #$00
093E-   20 5F 09    JSR   $095F

; burn some CPU cycles
0941-   24 00       BIT   $00

; increment value (used to decide what
; 4-and-4-encoded value to write) and
; loop for all 256 values
0943-   C8          INY
0944-   D0 CF       BNE   $0915
0946-   BD 8E C0    LDA   $C08E,X
0949-   BD 8C C0    LDA   $C08C,X
094C-   60          RTS

This routine writes out the protection
data on track $22.

!!!

This routine writes out the protection
data on track $22!!!

That is... not something you come
across every day.

Now let's look at the routine at $0800.

*800L

0800-   4C 96 08    JMP   $0896

*896L

; this is the nibble prologue of the
; region we just read from track $22
; into $0800..$0BFF
0896-   A9 A5       LDA   #$A5
0898-   85 00       STA   $00
089A-   A9 DF       LDA   #$DF
089C-   85 01       STA   $01
089E-   A9 D4       LDA   #$D4
08A0-   85 02       STA   $02

; this is the epilogue
08A2-   A9 CF       LDA   #$CF
08A4-   85 03       STA   $03
08A6-   A2 04       LDX   #$04
08A8-   A9 04       LDA   #$04
08AA-   4C 03 08    JMP   $0803

*803L

0803-   86 3C       STX   $3C
0805-   85 3B       STA   $3B
0807-   A0 00       LDY   #$00
0809-   84 3A       STY   $3A

; check write-protect flag
080B-   A6 08       LDX   $08
080D-   BD 8D C0    LDA   $C08D,X
0810-   BD 8E C0    LDA   $C08E,X
0813-   10 02       BPL   $0817

; exit early if disk is write-protected
0815-   38          SEC
0816-   60          RTS

; otherwise set up disk for writing
0817-   A0 06       LDY   #$06
0819-   A9 FF       LDA   #$FF
081B-   9D 8F C0    STA   $C08F,X
081E-   1D 8C C0    ORA   $C08C,X
0821-   26 4E       ROL   $4E
0823-   EA          NOP

; $0895 is an "RTS" so these are just
; to burn cycles (writing to disk is
; completely CPU-dependent and thus
; must be cycle-accurate)
0824-   20 95 08    JSR   $0895
0827-   20 95 08    JSR   $0895
082A-   9D 8D C0    STA   $C08D,X
082D-   1D 8C C0    ORA   $C08C,X
0830-   88          DEY
0831-   D0 F0       BNE   $0823
0833-   EA          NOP
0834-   A5 00       LDA   $00

; clears carry flag and writes 1 nibble
; (not shown)
0836-   20 8B 08    JSR   $088B
0839-   A5 01       LDA   $01
083B-   20 8B 08    JSR   $088B
083E-   A5 02       LDA   $02
0840-   20 8B 08    JSR   $088B

That writes the prologue.

; C=0 coming out of $088B, so this is
; an unconditional branch
0843-   EA          NOP
0844-   90 0C       BCC   $0852
...
; write $100 bytes from ($3A), which
; was initialized to $0400 (low byte
; from Y at $0807, high byte from A at
; $08A8)
0852-   B1 3A       LDA   ($3A),Y
0854-   48          PHA
0855-   4A          LSR
0856-   09 AA       ORA   #$AA
0858-   9D 8D C0    STA   $C08D,X
085B-   DD 8C C0    CMP   $C08C,X
085E-   C8          INY
085F-   D0 07       BNE   $0868
...
0868-   20 95 08    JSR   $0895
086B-   68          PLA
086C-   09 AA       ORA   #$AA
086E-   9D 8D C0    STA   $C08D,X
0871-   DD 8C C0    CMP   $C08C,X
...
; increment page and decrement counter
; (starts at #$04, set from X at $08A6)
0861-   E6 3B       INC   $3B
0863-   C6 3C       DEC   $3C
0865-   4C 6B 08    JMP   $086B

I won't show the entire execution flow,
but this confirms what I suspected:
this routine writes the region that we
just read from track $22, including
this routine.

Except it doesn't. Look again at the
zero page values. $3C is initially
#$04, so we're definitely writing $400
bytes, but not the $400 bytes at $0800.
This routine is exactly what you would
need in order to do that, but $3B is
initially #$04, so the data being
written is $0400..$07FF -- the text
page, which is being used by the backup
utility to display its user interface,
progress, swap-disk prompts, &c.

Continuing from $14F6...

; restore original byte (I had changed
; this to an "RTS" earlier)
*14F6:2C
*14F6L

14F6-   2C 10 C0    BIT   $C010
14F9-   AD FF 0B    LDA   $0BFF
14FC-   F0 62       BEQ   $1560
14FE-   C9 01       CMP   #$01
1500-   F0 04       BEQ   $1506
1502-   C9 02       CMP   #$02
1504-   F0 38       BEQ   $153E

$0BFF (the last of the $400 bytes read
from track $22) acts as a flag for the
user interface. If it's #$00, execution
continues. If it's #$01, it halts with
this message:

THIS DISK HAS ALREADY BEEN USED TO MAKE
A BACKUP

If $0BFF is #$02, it halts with a
different message:

THIS DISK CANNOT BE COPIED

Most of the backup process is simply
copying the unprotected tracks $00-$21,
with disk swap prompts. In the final
pass, it finally calls the two
routines we read from track $22, in
memory at $0800 and $0900:

; set internal flag to be checked the
; next time someone tries to use this
; backup utility on this disk
; (1=deauthorized master, so further
; attempts to make a backup will fail
; with an error that this disk has
; already been used to make a backup)
172B-   A9 01       LDA   #$01
172D-   8D FF 07    STA   $07FF

; clobber the writer routines used by
; the backup utility, by writing the
; $A5 $DF $D4 region but followed by
; the text page instead of the original
; code
1730-   20 00 08    JSR   $0800

; rewrite protection data so the disk
; will continue to function when booted
; normally
1733-   20 00 09    JSR   $0900

After the backup utility rewrites the
protection track on the master disk to
turn it into a deauthorized master
disk, it prompts you to swap disks one
last time and writes the protection
track to the protected backup:

; set internal flag to be checked the
; next time someone tries to use this
; backup utility on this disk
; (2=protected backup, so attempts to
; boot this disk and make a backup will
; fail with an error that this disk can
; not be copied)
176F-   A9 02       LDA   #$02
1771-   8D FF 07    STA   $07FF
1774-   20 00 08    JSR   $0800
1777-   20 00 09    JSR   $0900

Both the backup and the deauthorized
master end up with the protection data
but no code on track $22; the only
difference is the 1-byte flag that
leads to different error messages if
you try to use either disk to make
further copies. But that flag isn't why
they can't be used to make further
copies. The code that rewrites the
protection track (in memory at $0800..
$0BFF) never exists on the backup, and
it's been wiped from the deauthorized
master. Neither disk can be used to
make further copies because they
literally don't know how.

...But what if they did?

Despite the multi-layered tamper checks
on the protection code within the
program itself, there are no tamper
checks on the backup program's writer
routines. After the routines at $0800
and $0900 are read from track $22, we
could modify the initialization code at
$0896 -- specifically the start address
loaded into the accumulator at $08A8.

; new code --
; set start address in writer routine
; to $0800
172B-   A9 08       LDA   #$08
172D-   8D A9 08    STA   $08A9

; original code to call writer routines
1730-   20 00 08    JSR   $0800
1733-   20 00 09    JSR   $0900

Now that $08A9 contains #$08, the
subroutine writes out the original
protection code at $0800..$0BFF instead
of the text screen at $0400..$07FF.
That means the master disk will remain
a master disk, with all the privileges
to make another protected backup and
all the code to do so.

But wait! There's more!

The patch to $08A9 is still active when
we write the protection track to the
destination disk. The second call to
$0800 (at $1774) will again write the
original writer code at $0800..$0BFF --
now to our newly created "backup" --
and we end up with two master disks
instead of one.

No gods, infinite masters.

I have not applied this patch on my
cracked copy, because there's no point.
In fact, the backup utility hangs while
searching for the $A5 $DF $D4 nibble
sequence on track $22. Despite the
allure of a 1-bit crack, I applied two
additional patches to ignore the Esc
key during boot, so you can't access
the backup utility that doesn't work.
Never let your pride get in the way of
usability.

T00,S05,$39: C9 -> A9
T06,S0E,$44: C9 -> A9

Side B is unprotected.

Quod erat liberandum.


...............CHANGELOG...............

2024-12-27

- typos

2024-12-25

- initial release


A 4am crack                    No. 3296
------------------EOF------------------
